回顧昨天,我們終於使用 HTML、CSS 和 JavaScript,替智慧健康助理建立了第一個聊天介面。
原本只能透過 Postman 發送問題,現在已經可以直接在瀏覽器裡和 AI 對話。
不過昨天完成後,我發現還有一件事情沒有做好。
我們之前花了很多天建立健康紀錄功能,但網頁上卻只能聊天!
例如,前面已經完成:
這些功能都已經存在 Spring Boot 後端。
但使用者如果想查看今天喝了多少水,還是需要使用 Postman 呼叫 API。
如果想查看之前的聊天,也要手動輸入 conversationId。
這樣好像還不算是一個真正方便使用的健康助理 XD
所以今天決定把這些功能整合到同一個網頁。
今天的目標是:
透過 JavaScript Fetch API,整合健康摘要、AI 文字摘要與歷史聊天紀錄,讓使用者可以在同一個網頁查看健康資料、取得 AI 摘要,以及接續之前的對話。
今天會延續 Day 28 的網頁,不需要重新建立 Spring Boot 專案。
預計新增五個功能:
最後的網頁大概會長這樣:
┌─────────────────────────────────────────────────┐
│ │
│ 智慧健康助理 │
│ AI Health Assistant │
│ │
├──────────────────┬──────────────────────────────┤
│ │ │
│ 每日健康摘要 │ AI 健康聊天 │
│ │ │
│ 日期 │ AI:你好!有什麼想了解的? │
│ [2026-10-17] │ │
│ │ 使用者:什麼是 BMI? │
│ 飲水量 │ │
│ 1500 ml │ AI:BMI 是身體質量指數。 │
│ │ │
│ 睡眠時間 │ 使用者:怎麼計算? │
│ 7.5 小時 │ │
│ │ AI:體重除以身高的平方。 │
│ [產生 AI 摘要] │ │
│ │ │
│ AI 健康摘要 ├──────────────────────────────┤
│ 今日已記錄…… │ 請輸入問題…… [送出] │
│ │ │
├──────────────────┴──────────────────────────────┤
│ │
│ 歷史對話 │
│ │
│ [對話 A] [對話 B] [對話 C] │
│ │
└─────────────────────────────────────────────────┘
今天會以功能整合為主。
不會使用 React、Vue 或其他前端框架。
因為我希望先透過最基本的 HTML、CSS 和 JavaScript,理解前後端資料是怎麼串接的。
在開始寫網頁之前,先回顧之前建立的 API。
| 功能 | HTTP Method | API |
|---|---|---|
| 查詢每日健康摘要 | GET | /health/summary?date=YYYY-MM-DD |
| 產生 AI 每日健康摘要 | GET | /health/ai/summary?date=YYYY-MM-DD |
| AI 多輪對話 | POST | /health/ai/conversation |
| 查詢所有對話 | GET | /health/ai/conversations |
| 查詢指定對話 | GET | /health/ai/conversations/{id} |
這些 API 都已經在前面的文章完成。
所以今天主要要做的事情,就是使用 JavaScript 呼叫它們。
例如:
fetch("/health/summary?date=2026-10-17")
就可以取得指定日期的健康資料。
而:
fetch("/health/ai/conversations")
則可以取得之前保存的聊天列表。
今天我們會把這些 API 串接到網頁按鈕和畫面上。
目前我們的專案架構可以表示成:
使用者
↓
瀏覽器
│
├── HTML
│
├── CSS
│
└── JavaScript
↓
Fetch API
↓
Spring Boot
│
├── HealthSummaryController
│ ↓
│ HealthSummaryService
│ ↓
│ WaterService / SleepService
│ ↓
│ H2 Database
│
└── AIController
│
├── AIHealthSummaryService
│ ↓
│ AIService
│
├── AIConversationService
│
└── ConversationManagementService
↓
ConversationRepository
↓
H2 Database
今天最重要的觀念是:
前端不需要知道資料庫怎麼查詢,也不需要知道 AI API 怎麼呼叫。
它只需要透過 HTTP Request,向 Spring Boot 取得資料。
而後端則負責:
資料查詢
資料整理
AI 呼叫
對話保存
這樣就可以讓前後端各自負責不同的工作。
昨天的網頁只有聊天區域。
今天準備加入:
每日健康摘要
以及:
歷史對話列表
因此需要修改:
src/main/resources/static/index.html
這次我們直接更新成完整版本。
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>智慧健康助理</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="app-container">
<header class="app-header">
<h1>智慧健康助理</h1>
<p>AI Health Assistant</p>
</header>
<div class="main-layout">
<!-- 每日健康摘要 -->
<aside class="health-panel">
<h2>每日健康摘要</h2>
<label for="summaryDate">
選擇日期
</label>
<input
type="date"
id="summaryDate"
>
<button
id="loadSummaryButton"
type="button"
>
查詢健康紀錄
</button>
<div class="health-card">
<h3>今日飲水量</h3>
<p id="waterValue">
尚未查詢
</p>
</div>
<div class="health-card">
<h3>今日睡眠時間</h3>
<p id="sleepValue">
尚未查詢
</p>
</div>
<button
id="aiSummaryButton"
type="button"
>
產生 AI 健康摘要
</button>
<div class="ai-summary-card">
<h3>AI 健康摘要</h3>
<p id="aiSummaryText">
點擊按鈕產生摘要。
</p>
</div>
<p
id="summaryStatus"
class="status-text"
role="status"
></p>
</aside>
<!-- AI 聊天區 -->
<section class="chat-panel">
<div class="chat-header">
<h2>AI 健康聊天</h2>
<button
id="newChatButton"
type="button"
>
新對話
</button>
</div>
<div
id="chatMessages"
class="chat-messages"
aria-live="polite"
aria-relevant="additions"
>
<div class="message assistant">
<div class="message-content">
你好!我是智慧健康助理。
有什麼健康相關問題想了解嗎?
</div>
</div>
</div>
<p
id="chatStatus"
class="status-text"
role="status"
></p>
<form
id="chatForm"
class="chat-input-area"
>
<input
type="text"
id="questionInput"
placeholder="請輸入你的問題……"
maxlength="500"
autocomplete="off"
aria-label="聊天問題"
required
>
<button
id="sendButton"
type="submit"
>
送出
</button>
</form>
</section>
</div>
<!-- 歷史對話 -->
<section class="history-panel">
<div class="history-header">
<h2>歷史對話</h2>
<button
id="refreshHistoryButton"
type="button"
>
重新整理
</button>
</div>
<div id="conversationList">
<p>正在載入對話紀錄……</p>
</div>
<p
id="historyStatus"
class="status-text"
role="status"
></p>
</section>
<footer class="app-footer">
本系統僅提供一般健康資訊,
不取代專業醫療診斷或治療。
</footer>
</div>
<script src="/app.js"></script>
</body>
</html>
這次的 HTML 比昨天多了不少內容。
但其實可以分成三個主要區域。
第一個區域是:
<aside class="health-panel">
主要負責顯示每日健康資料。
包含:
日期選擇
飲水總量
睡眠總時數
AI 健康摘要
其中:
<input type="date" id="summaryDate">
可以讓使用者選擇日期。
例如:
2026-10-17
接著點擊:
查詢健康紀錄
就可以透過 API 查詢指定日期的資料。
第二個區域是:
<section class="chat-panel">
這裡延續昨天建立的 AI 聊天功能。
包含:
聊天訊息
問題輸入框
送出按鈕
新對話按鈕
所以今天不需要重新建立後端的聊天 API。
只要沿用昨天的 JavaScript 邏輯即可。
第三個區域是:
<section class="history-panel">
負責顯示歷史對話。
例如:
歷史對話
2026-10-17 14:00
4 則訊息
2026-10-17 14:05
2 則訊息
使用者可以點擊其中一段對話,重新載入之前的聊天內容。
這樣就不用再手動複製 conversationId。
接下來要修改昨天建立的:
style.css
因為今天的畫面不再只有聊天視窗,而是多了健康摘要與歷史對話區域。
所以我們使用 CSS Grid 搭配 Flexbox 排版。
將 style.css 更新成以下內容:
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
font-family:
Arial,
"Noto Sans TC",
sans-serif;
background-color: #f1f7f5;
color: #333333;
}
button {
cursor: pointer;
font-family: inherit;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.app-container {
max-width: 1200px;
margin: 0 auto;
background-color: white;
border-radius: 18px;
overflow: hidden;
box-shadow:
0 8px 30px rgba(0, 0, 0, 0.08);
}
.app-header {
padding: 24px;
background-color: #4a9b84;
color: white;
text-align: center;
}
.app-header h1 {
margin: 0;
font-size: 28px;
}
.app-header p {
margin: 8px 0 0;
font-size: 14px;
}
.main-layout {
display: grid;
grid-template-columns:
minmax(260px, 1fr)
minmax(0, 2fr);
min-height: 600px;
}
.health-panel {
padding: 24px;
background-color: #f9fcfb;
border-right: 1px solid #eeeeee;
}
.health-panel h2 {
margin-top: 0;
font-size: 20px;
}
.health-panel label {
display: block;
margin-bottom: 8px;
}
.health-panel input {
width: 100%;
padding: 10px;
border: 1px solid #dddddd;
border-radius: 8px;
margin-bottom: 12px;
}
.health-panel button {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background-color: #4a9b84;
color: white;
margin-bottom: 16px;
}
.health-panel button:hover:not(:disabled) {
background-color: #397c69;
}
.health-card {
padding: 16px;
margin-bottom: 14px;
background-color: white;
border: 1px solid #e2ece8;
border-radius: 12px;
}
.health-card h3 {
margin: 0 0 10px;
font-size: 15px;
color: #666666;
}
.health-card p {
margin: 0;
font-size: 24px;
font-weight: bold;
color: #397c69;
}
.ai-summary-card {
padding: 16px;
background-color: #eaf5f0;
border-radius: 12px;
}
.ai-summary-card h3 {
margin-top: 0;
font-size: 16px;
}
.ai-summary-card p {
margin-bottom: 0;
line-height: 1.8;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.chat-panel {
min-width: 0;
display: flex;
flex-direction: column;
height: 600px;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 24px;
border-bottom: 1px solid #eeeeee;
}
.chat-header h2 {
margin: 0;
font-size: 20px;
}
.chat-header button {
padding: 9px 14px;
border: 1px solid #4a9b84;
border-radius: 8px;
background-color: white;
color: #397c69;
}
.chat-messages {
flex: 1;
padding: 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
}
.message.user {
justify-content: flex-end;
}
.message.assistant {
justify-content: flex-start;
}
.message-content {
max-width: 75%;
padding: 12px 16px;
border-radius: 14px;
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.message.user .message-content {
background-color: #d9f0e8;
color: #244b3f;
}
.message.assistant .message-content {
background-color: #f1f3f5;
}
.chat-input-area {
display: flex;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #eeeeee;
}
.chat-input-area input {
flex: 1;
min-width: 0;
padding: 12px;
border: 1px solid #dddddd;
border-radius: 10px;
}
.chat-input-area button {
padding: 12px 20px;
border: none;
border-radius: 10px;
background-color: #4a9b84;
color: white;
}
.history-panel {
padding: 24px;
border-top: 1px solid #eeeeee;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.history-header h2 {
margin: 0;
font-size: 20px;
}
.history-header button {
padding: 9px 14px;
border: 1px solid #4a9b84;
border-radius: 8px;
background-color: white;
color: #397c69;
}
#conversationList {
display: grid;
grid-template-columns:
repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.conversation-item {
padding: 16px;
text-align: left;
background-color: #f9fcfb;
border: 1px solid #e2ece8;
border-radius: 10px;
color: #333333;
}
.conversation-item:hover {
border-color: #4a9b84;
}
.conversation-item.active {
background-color: #e0f1e9;
border-color: #4a9b84;
}
.conversation-item strong {
display: block;
margin-bottom: 8px;
}
.conversation-item span {
display: block;
font-size: 13px;
color: #666666;
}
.status-text {
min-height: 20px;
margin: 8px 20px;
font-size: 13px;
color: #777777;
}
.app-footer {
padding: 18px;
text-align: center;
background-color: #fafafa;
color: #888888;
font-size: 12px;
}
@media (max-width: 800px) {
body {
padding: 0;
}
.app-container {
border-radius: 0;
}
.main-layout {
grid-template-columns: 1fr;
}
.health-panel {
border-right: none;
border-bottom: 1px solid #eeeeee;
}
.chat-panel {
height: 550px;
}
.message-content {
max-width: 88%;
}
}
這次 CSS 最主要的改變,是加入:
display: grid;
以及:
grid-template-columns:
minmax(260px, 1fr)
minmax(0, 2fr);
這代表桌面版會分成左右兩個區域。
左邊顯示健康摘要。
右邊顯示 AI 聊天。
當螢幕寬度小於 800px 時,則改成上下排列。
這樣在手機上也比較容易閱讀。
還記得 Day 20 嗎?
我們建立了:
GET /health/summary?date=2026-10-17
這個 API 會整合飲水與睡眠紀錄。
例如回傳:
{
"date": "2026-10-17",
"water": {
"totalWater": 1500,
"unit": "ml",
"hasRecord": true
},
"sleep": {
"totalHours": 7.5,
"unit": "hours",
"hasRecord": true
}
}
其中:
water.totalWater
代表指定日期的飲水總量。
而:
sleep.totalHours
代表指定日期的睡眠總時數。
另外還有:
hasRecord
用來判斷當天是否有紀錄。
這個欄位很重要。
因為:
沒有紀錄
不代表:
實際飲水量或睡眠時間是 0
所以今天的網頁也會保留這個區別。
接下來打開:
app.js
今天我們會重新整理 JavaScript,將健康摘要、聊天和歷史對話功能放在同一個檔案。
先看健康摘要的核心程式:
async function loadHealthSummary() {
const date = summaryDate.value;
if (!date) {
summaryStatus.textContent = "請先選擇日期。";
return;
}
summaryStatus.textContent = "正在查詢健康紀錄……";
try {
const response = await fetch(
`/health/summary?date=${encodeURIComponent(date)}`
);
if (!response.ok) {
throw new Error("健康摘要查詢失敗");
}
const data = await response.json();
waterValue.textContent = data.water.hasRecord
? `${data.water.totalWater} ml`
: "當日尚無紀錄";
sleepValue.textContent = data.sleep.hasRecord
? `${data.sleep.totalHours} 小時`
: "當日尚無紀錄";
summaryStatus.textContent = "健康紀錄查詢完成。";
} catch (error) {
console.error(error);
summaryStatus.textContent =
"無法取得健康紀錄,請稍後再試。";
}
}
這段程式會完成三件事情。
首先取得使用者選擇的日期:
const date = summaryDate.value;
接著呼叫:
fetch(`/health/summary?date=${date}`)
最後根據 API 回傳的 JSON 更新畫面。
例如:
waterValue.textContent = "1500 ml";
以及:
sleepValue.textContent = "7.5 小時";
這樣就可以直接在網頁上看到健康資料。
今天有一個細節不能忽略。
假設使用者當天沒有輸入任何飲水紀錄。
API 可能回傳:
{
"totalWater": 0,
"unit": "ml",
"hasRecord": false
}
如果我們直接顯示:
今日飲水量:0 ml
使用者可能會誤以為自己今天完全沒有喝水。
但實際上只是:
系統沒有紀錄
所以我們使用:
data.water.hasRecord
判斷。
如果是:
true
就顯示飲水總量。
如果是:
false
則顯示:
當日尚無紀錄
睡眠紀錄也是相同概念。
這也是健康資訊系統很重要的一個觀念:
沒有資料,不應該直接解讀成數值為零。
在 Day 23,我們已經建立:
GET /health/ai/summary?date=2026-10-17
這個 API 會先取得指定日期的健康紀錄,再透過 AI 產生自然語言摘要。
例如:
{
"date": "2026-10-17",
"summary": "今天已記錄飲水量 1500 ml,睡眠時間 7.5 小時。可以持續記錄,方便日後觀察生活習慣。"
}
今天我們準備在網頁上加入:
產生 AI 健康摘要
按鈕。
使用者點擊後,就可以直接看到 AI 產生的文字。
核心程式如下:
async function generateAISummary() {
const date = summaryDate.value;
if (!date) {
summaryStatus.textContent = "請先選擇日期。";
return;
}
aiSummaryButton.disabled = true;
aiSummaryText.textContent = "AI 正在整理健康紀錄……";
try {
const response = await fetch(
`/health/ai/summary?date=${encodeURIComponent(date)}`
);
if (!response.ok) {
throw new Error("AI 摘要產生失敗");
}
const data = await response.json();
aiSummaryText.textContent = data.summary;
} catch (error) {
console.error(error);
aiSummaryText.textContent =
"目前無法產生 AI 摘要,請稍後再試。";
} finally {
aiSummaryButton.disabled = false;
}
}
這裡的流程是:
使用者選擇日期
↓
點擊產生 AI 摘要
↓
JavaScript 呼叫 Spring Boot
↓
HealthSummaryService 查詢健康資料
↓
AIHealthSummaryService 建立 Prompt
↓
AIService 呼叫外部 AI API
↓
回傳 AI 文字摘要
↓
JavaScript 顯示在網頁上
這樣就完成 AI 健康摘要的前後端整合。
不過要注意:
每次點擊產生 AI 摘要,都可能觸發一次付費的 AI API 呼叫。
所以今天先透過按鈕讓使用者主動產生摘要,而不是每次開啟網頁就自動呼叫 AI。
另外,之前的 AI 摘要 API 使用 GET 觸發模型生成,是教學階段的簡化設計。
正式系統更適合使用 POST,並加入驗證、速率限制和快取機制。
在 Day 27,我們建立了:
GET /health/ai/conversations
這個 API 可以查詢所有對話。
例如:
[
{
"conversationId": "550e8400-e29b-41d4-a716-446655440000",
"createdAt": "2026-10-17T14:00:00",
"messageCount": 4
},
{
"conversationId": "9c1e4b20-2f7a-4c88-a2d3-123456789abc",
"createdAt": "2026-10-17T14:05:00",
"messageCount": 2
}
]
今天要把這些資料顯示在網頁上。
每一段對話都會變成一個可以點擊的項目。
例如:
┌──────────────────────┐
│ 2026/10/17 14:00 │
│ 4 則訊息 │
└──────────────────────┘
點擊後,就會載入完整聊天紀錄。
核心程式如下:
async function loadConversationList() {
historyStatus.textContent = "正在載入歷史對話……";
try {
const response = await fetch(
"/health/ai/conversations"
);
if (!response.ok) {
throw new Error("歷史對話查詢失敗");
}
const conversations = await response.json();
conversationList.replaceChildren();
if (conversations.length === 0) {
const emptyText = document.createElement("p");
emptyText.textContent = "目前沒有歷史對話。";
conversationList.appendChild(emptyText);
historyStatus.textContent = "";
return;
}
conversations.forEach(conversation => {
const button = document.createElement("button");
button.type = "button";
button.className = "conversation-item";
const title = document.createElement("strong");
title.textContent =
formatDateTime(conversation.createdAt);
const count = document.createElement("span");
count.textContent =
`${conversation.messageCount} 則訊息`;
button.appendChild(title);
button.appendChild(count);
button.addEventListener("click", () => {
loadConversation(conversation.conversationId);
});
conversationList.appendChild(button);
});
historyStatus.textContent = "";
} catch (error) {
console.error(error);
historyStatus.textContent =
"無法載入歷史對話。";
}
}
這裡我們使用:
conversations.forEach()
逐一處理 API 回傳的對話資料。
每一段對話都會建立一個:
<button>
再透過:
addEventListener("click", ...)
設定點擊事件。
當使用者點擊某段對話時,就會呼叫:
loadConversation(conversation.conversationId);
取得完整聊天紀錄。
接下來使用 Day 27 的另一個 API:
GET /health/ai/conversations/{id}
例如:
GET /health/ai/conversations/550e8400-e29b-41d4-a716-446655440000
預期回傳:
{
"conversationId": "550e8400-e29b-41d4-a716-446655440000",
"messages": [
{
"id": 1,
"role": "user",
"content": "什麼是 BMI?",
"createdAt": "2026-10-17T14:00:01"
},
{
"id": 2,
"role": "assistant",
"content": "BMI 是身體質量指數。",
"createdAt": "2026-10-17T14:00:03"
}
]
}
接著我們就可以把這些訊息顯示在聊天視窗中。
核心程式如下:
async function loadConversation(id) {
try {
const response = await fetch(
`/health/ai/conversations/${encodeURIComponent(id)}`
);
if (!response.ok) {
throw new Error("對話查詢失敗");
}
const data = await response.json();
conversationId = data.conversationId;
chatMessages.replaceChildren();
data.messages.forEach(message => {
addMessage(
message.role,
message.content
);
});
} catch (error) {
console.error(error);
historyStatus.textContent =
"無法載入指定對話。";
}
}
這樣就可以把資料庫中的聊天紀錄重新顯示在網頁上。
而且因為:
conversationId = data.conversationId;
所以使用者接著輸入新問題時,仍然會使用同一個對話 ID。
也就是說:
不只能查看舊對話,還可以直接接續聊天!
前面已經分別介紹:
健康摘要查詢
AI 健康摘要
歷史對話列表
指定對話查詢
接下來我們要把這些功能,和昨天的聊天功能整合在一起。
請將:
src/main/resources/static/app.js
更新成以下完整版本。
這份程式碼包含今天所有功能,可以直接搭配前面的 HTML 和 CSS 使用。
// ===============================
// API URLs
// ===============================
const CHAT_API = "/health/ai/conversation";
const HISTORY_API = "/health/ai/conversations";
const HEALTH_SUMMARY_API = "/health/summary";
const AI_SUMMARY_API = "/health/ai/summary";
// ===============================
// HTML Elements
// ===============================
const summaryDate =
document.getElementById("summaryDate");
const loadSummaryButton =
document.getElementById("loadSummaryButton");
const aiSummaryButton =
document.getElementById("aiSummaryButton");
const waterValue =
document.getElementById("waterValue");
const sleepValue =
document.getElementById("sleepValue");
const aiSummaryText =
document.getElementById("aiSummaryText");
const summaryStatus =
document.getElementById("summaryStatus");
const chatForm =
document.getElementById("chatForm");
const questionInput =
document.getElementById("questionInput");
const sendButton =
document.getElementById("sendButton");
const newChatButton =
document.getElementById("newChatButton");
const chatMessages =
document.getElementById("chatMessages");
const chatStatus =
document.getElementById("chatStatus");
const conversationList =
document.getElementById("conversationList");
const refreshHistoryButton =
document.getElementById("refreshHistoryButton");
const historyStatus =
document.getElementById("historyStatus");
// ===============================
// State
// ===============================
let conversationId = null;
let isChatLoading = false;
let isConversationLoading = false;
// ===============================
// Helper Functions
// ===============================
function formatDateTime(value) {
if (!value) {
return "未知時間";
}
return value.replace("T", " ").slice(0, 16);
}
function addMessage(role, content) {
const messageDiv = document.createElement("div");
messageDiv.classList.add("message", role);
const contentDiv = document.createElement("div");
contentDiv.classList.add("message-content");
contentDiv.textContent = content;
messageDiv.appendChild(contentDiv);
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function setChatLoading(loading) {
isChatLoading = loading;
sendButton.disabled = loading;
questionInput.disabled = loading;
newChatButton.disabled = loading;
chatStatus.textContent = loading
? "AI 正在思考中……"
: "";
}
function setConversationLoading(loading) {
isConversationLoading = loading;
refreshHistoryButton.disabled = loading;
newChatButton.disabled = loading || isChatLoading;
sendButton.disabled = loading || isChatLoading;
questionInput.disabled = loading || isChatLoading;
}
// ===============================
// Daily Health Summary
// ===============================
async function loadHealthSummary() {
const date = summaryDate.value;
if (!date) {
summaryStatus.textContent = "請先選擇日期。";
return;
}
loadSummaryButton.disabled = true;
summaryStatus.textContent = "正在查詢健康紀錄……";
try {
const response = await fetch(
`${HEALTH_SUMMARY_API}?date=${encodeURIComponent(date)}`
);
if (!response.ok) {
throw new Error("健康摘要查詢失敗");
}
const data = await response.json();
waterValue.textContent = data.water.hasRecord
? `${data.water.totalWater} ml`
: "當日尚無紀錄";
sleepValue.textContent = data.sleep.hasRecord
? `${data.sleep.totalHours} 小時`
: "當日尚無紀錄";
summaryStatus.textContent = "健康紀錄查詢完成。";
} catch (error) {
console.error(error);
waterValue.textContent = "查詢失敗";
sleepValue.textContent = "查詢失敗";
summaryStatus.textContent =
"無法取得健康紀錄,請稍後再試。";
} finally {
loadSummaryButton.disabled = false;
}
}
// ===============================
// AI Daily Summary
// ===============================
async function generateAISummary() {
const date = summaryDate.value;
if (!date) {
summaryStatus.textContent = "請先選擇日期。";
return;
}
aiSummaryButton.disabled = true;
aiSummaryText.textContent = "AI 正在整理健康紀錄……";
try {
const response = await fetch(
`${AI_SUMMARY_API}?date=${encodeURIComponent(date)}`
);
if (!response.ok) {
throw new Error("AI 摘要產生失敗");
}
const data = await response.json();
aiSummaryText.textContent = data.summary;
} catch (error) {
console.error(error);
aiSummaryText.textContent =
"目前無法產生 AI 摘要,請稍後再試。";
} finally {
aiSummaryButton.disabled = false;
}
}
// ===============================
// AI Chat
// ===============================
async function sendQuestion(question) {
if (isChatLoading || isConversationLoading) {
return;
}
setChatLoading(true);
addMessage("user", question);
const requestBody = {
question: question
};
if (conversationId !== null) {
requestBody.conversationId = conversationId;
}
try {
const response = await fetch(CHAT_API, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
throw new Error(
"聊天 API 請求失敗:" + response.status
);
}
const data = await response.json();
if (!data.conversationId || !data.answer) {
throw new Error("聊天 API 回傳格式不正確");
}
conversationId = data.conversationId;
addMessage("assistant", data.answer);
await loadConversationList();
} catch (error) {
console.error(error);
addMessage(
"assistant",
"抱歉,目前無法取得回答,請稍後再試。"
);
} finally {
setChatLoading(false);
questionInput.focus();
}
}
// ===============================
// Conversation List
// ===============================
async function loadConversationList() {
historyStatus.textContent = "正在載入歷史對話……";
try {
const response = await fetch(HISTORY_API);
if (!response.ok) {
throw new Error("歷史對話查詢失敗");
}
const conversations = await response.json();
conversationList.replaceChildren();
if (conversations.length === 0) {
const emptyText = document.createElement("p");
emptyText.textContent = "目前沒有歷史對話。";
conversationList.appendChild(emptyText);
historyStatus.textContent = "";
return;
}
conversations.forEach(conversation => {
const button = document.createElement("button");
button.type = "button";
button.className = "conversation-item";
if (conversation.conversationId === conversationId) {
button.classList.add("active");
}
const title = document.createElement("strong");
title.textContent =
formatDateTime(conversation.createdAt);
const count = document.createElement("span");
count.textContent =
`${conversation.messageCount} 則訊息`;
button.appendChild(title);
button.appendChild(count);
button.addEventListener("click", () => {
if (isChatLoading || isConversationLoading) {
return;
}
loadConversation(conversation.conversationId);
});
conversationList.appendChild(button);
});
historyStatus.textContent = "";
} catch (error) {
console.error(error);
historyStatus.textContent =
"無法載入歷史對話。";
}
}
// ===============================
// Load Conversation
// ===============================
async function loadConversation(id) {
if (isChatLoading || isConversationLoading) {
return;
}
setConversationLoading(true);
historyStatus.textContent = "正在載入對話……";
try {
const response = await fetch(
`${HISTORY_API}/${encodeURIComponent(id)}`
);
if (!response.ok) {
throw new Error("指定對話查詢失敗");
}
const data = await response.json();
conversationId = data.conversationId;
chatMessages.replaceChildren();
data.messages.forEach(message => {
addMessage(
message.role,
message.content
);
});
historyStatus.textContent = "";
await loadConversationList();
} catch (error) {
console.error(error);
historyStatus.textContent =
"無法載入指定對話。";
} finally {
setConversationLoading(false);
questionInput.focus();
}
}
// ===============================
// Event Listeners
// ===============================
loadSummaryButton.addEventListener(
"click",
loadHealthSummary
);
aiSummaryButton.addEventListener(
"click",
generateAISummary
);
refreshHistoryButton.addEventListener(
"click",
loadConversationList
);
chatForm.addEventListener("submit", function (event) {
event.preventDefault();
if (isChatLoading || isConversationLoading) {
return;
}
const question = questionInput.value.trim();
if (question === "") {
return;
}
if (question.length > 500) {
alert("問題不能超過 500 個字元。");
return;
}
questionInput.value = "";
sendQuestion(question);
});
newChatButton.addEventListener("click", function () {
if (isChatLoading || isConversationLoading) {
return;
}
conversationId = null;
chatMessages.replaceChildren();
addMessage(
"assistant",
"已開始新對話!你可以輸入新的健康相關問題。"
);
loadConversationList();
questionInput.focus();
});
// ===============================
// Initialize
// ===============================
function getTodayLocalDate() {
const now = new Date();
const year = now.getFullYear();
const month = String(
now.getMonth() + 1
).padStart(2, "0");
const day = String(
now.getDate()
).padStart(2, "0");
return `${year}-${month}-${day}`;
}
summaryDate.value = getTodayLocalDate();
loadHealthSummary();
loadConversationList();
這樣我們就把今天的所有功能整合完成了!
今天的程式裡面有三個重要變數:
let conversationId = null;
let isChatLoading = false;
let isConversationLoading = false;
其中:
conversationId
代表目前正在使用的對話。
而:
isChatLoading
代表 AI 是否正在回覆。
最後:
isConversationLoading
代表是否正在載入歷史對話。
為什麼需要這些變數?
假設使用者正在等待 AI 回答時,突然點擊另一段歷史對話。
如果沒有控制好,可能發生:
使用者送出問題
↓
AI 正在產生回答
↓
使用者切換對話
↓
舊對話的 AI 回答出現
↓
畫面內容混在一起
所以今天先透過:
isChatLoading
以及:
isConversationLoading
避免在請求進行時切換對話。
這是一個簡單的處理方式。
正式系統還可以使用 AbortController、請求識別碼,以及更完整的狀態管理,避免非同步請求造成畫面不一致。
接下來確認今天修改的三個檔案:
src/main/resources/static
│
├── index.html
├── style.css
└── app.js
都已經儲存。
然後重新啟動 Spring Boot。
如果使用 Maven:
./mvnw spring-boot:run
Windows 可以使用:
.\mvnw.cmd spring-boot:run
接著打開:
http://localhost:8080/
如果一切正常,就可以看到今天整合後的智慧健康助理網頁。
首先選擇日期:
2026-10-17
再點擊:
查詢健康紀錄
如果當天已經有資料,畫面可能顯示:
今日飲水量
1500 ml
以及:
今日睡眠時間
7.5 小時
如果當天沒有紀錄,則會顯示:
當日尚無紀錄
這樣就完成第一個測試。
如果資料庫還沒有測試資料,可以先透過之前的飲水與睡眠 API 新增紀錄。
接下來點擊:
產生 AI 健康摘要
畫面會先顯示:
AI 正在整理健康紀錄……
接著等待 Spring Boot 呼叫 AI API。
如果成功,可能看到:
今天已記錄飲水量 1500 ml,
睡眠時間 7.5 小時。
可以持續記錄每天的生活習慣,
方便之後觀察變化。
實際回覆會依照模型輸出而不同。
這樣就代表我們成功把 Day 23 的 AI 健康摘要功能整合到網頁!
接著在右邊的聊天區輸入:
什麼是 BMI?
按下送出。
預期會先看到:
使用者:什麼是 BMI?
接著:
AI 正在思考中……
最後顯示 AI 回答。
接著再輸入:
那它怎麼計算?
如果多輪對話功能正常,AI 就可以根據前面的問題理解:
它
指的是:
BMI
這樣就完成聊天功能測試。
接下來往下捲動到:
歷史對話
這個區域。
應該可以看到剛剛建立的對話。
例如:
2026-10-17 14:00
4 則訊息
接著點擊其中一段對話。
網頁就會呼叫:
GET /health/ai/conversations/{id}
並重新顯示之前的聊天紀錄。
例如:
User:什麼是 BMI?
AI:BMI 是身體質量指數。
User:那它怎麼計算?
AI:體重除以身高的平方。
這樣就完成歷史對話載入功能。
這是今天很重要的一個測試。
首先建立一段新對話:
User:什麼是 BMI?
AI:BMI 是身體質量指數。
接著按下:
新對話
建立另一段聊天。
例如:
User:為什麼規律作息很重要?
AI:規律作息有助於維持穩定的生活節奏。
接下來在歷史對話列表中,點擊第一段 BMI 對話。
畫面就會重新載入:
User:什麼是 BMI?
AI:BMI 是身體質量指數。
這時候再輸入:
可以給我一個計算範例嗎?
因為 JavaScript 已經更新:
conversationId = data.conversationId;
所以這個問題會接續原本的 BMI 對話。
而不是接續剛剛的睡眠對話。
這樣就代表:
歷史對話不只能查看,也能重新接續!
昨天我們提到:
conversationId
只保存在 JavaScript 記憶體中。
因此重新整理網頁後:
conversationId = null;
目前的聊天狀態就會消失。
不過今天我們已經整合:
GET /health/ai/conversations
所以即使重新整理網頁,也可以重新載入歷史對話列表。
接著點擊之前的對話,就能恢復聊天紀錄。
這樣就不需要額外把完整聊天內容保存到瀏覽器裡。
因為真正的資料仍然保存在 H2 Database。
今天網頁顯示:
飲水量
睡眠時間
但這些只是使用者自行輸入的紀錄。
例如:
1500 ml
不一定代表使用者當天所有的實際飲水量。
同樣地:
7.5 小時
也不能單獨用來判斷睡眠品質。
因此 AI 摘要只能協助整理紀錄,不能直接當成醫療評估結果。
今天整合的:
GET /health/ai/conversations
會回傳資料庫中的所有對話。
目前我們仍然以單人本機練習為前提。
如果未來公開部署,就不能讓所有人都查看相同的聊天紀錄。
正式系統至少需要:
使用者登入
↓
驗證身分
↓
確認資料所有權
↓
只回傳自己的健康資料與對話
這部分會是未來很重要的改進方向。
今天的網頁有兩個地方會呼叫外部 AI API。
第一個:
產生 AI 健康摘要
第二個:
AI 健康聊天
每次呼叫都可能產生 API 使用費用。
所以正式系統需要考慮:
API 使用次數限制
使用量監控
快取機制
以及:
錯誤重試策略
避免不必要的重複請求。
雖然今天可以在網頁查看:
飲水紀錄
以及:
睡眠紀錄
但目前還沒有提供新增資料的表單。
也就是說,使用者仍然需要透過之前的 API 新增健康紀錄。
未來可以在網頁上加入:
新增飲水紀錄
新增睡眠紀錄
以及:
BMI 計算表單
讓健康紀錄功能更加完整。
今天先以整合查詢與 AI 功能為主。
今天沒有新增 Java 類別。
主要修改:
index.html
style.css
app.js
目前的專案架構如下:
health-assistant
│
├── src/main/java/com/example/healthassistant
│ │
│ ├── HealthAssistantApplication.java
│ │
│ ├── controller
│ │ ├── HealthController.java
│ │ ├── WaterController.java
│ │ ├── SleepController.java
│ │ ├── HealthSummaryController.java
│ │ └── AIController.java
│ │
│ ├── service
│ │ ├── BmiService.java
│ │ ├── WaterService.java
│ │ ├── SleepService.java
│ │ ├── HealthSummaryService.java
│ │ ├── AIService.java
│ │ ├── AIHealthSummaryService.java
│ │ ├── AIChatService.java
│ │ ├── AIConversationService.java
│ │ ├── ConversationPersistenceService.java
│ │ └── ConversationManagementService.java
│ │
│ ├── repository
│ │ ├── WaterRepository.java
│ │ ├── SleepRepository.java
│ │ └── ConversationRepository.java
│ │
│ ├── dto
│ │ ├── HealthSummaryResponse.java
│ │ ├── AIHealthSummaryResponse.java
│ │ ├── ConversationRequest.java
│ │ ├── ConversationResponse.java
│ │ ├── ConversationMessage.java
│ │ ├── ConversationListResponse.java
│ │ └── ConversationDetailResponse.java
│ │
│ └── exception
│ ├── GlobalExceptionHandler.java
│ └── ConversationNotFoundException.java
│
├── src/main/resources
│ │
│ ├── application.properties
│ ├── schema.sql
│ │
│ └── static
│ ├── index.html
│ ├── style.css
│ └── app.js
│
└── pom.xml
這裡只列出部分 DTO,其他之前建立的類別仍然保留。
目前智慧健康助理的主要功能,已經可以透過同一個網頁操作。
今天成功把之前建立的健康紀錄與 AI 功能整合到同一個網頁!
原本我們有很多分散的 API。
例如:
健康摘要 API
AI 摘要 API
AI 聊天 API
歷史對話 API
雖然各自都可以正常使用,但對一般使用者來說,操作起來並不方便。
今天透過 HTML、CSS 和 JavaScript,終於把這些功能整合起來。
今天完成的功能包括:
現在智慧健康助理已經具備:
智慧健康助理
│
├── 健康紀錄功能
│ ├── BMI 計算與分類
│ ├── 飲水紀錄
│ ├── 睡眠紀錄
│ └── 每日健康摘要
│
├── AI 功能
│ ├── AI 健康知識問答
│ ├── AI 每日健康摘要
│ ├── AI 多輪對話
│ └── AI 對話紀錄管理
│
├── 資料庫
│ ├── 飲水紀錄
│ ├── 睡眠紀錄
│ └── AI 聊天紀錄
│
└── 網頁介面
├── 每日健康摘要查詢
├── AI 健康摘要產生
├── AI 聊天
├── 歷史對話列表
└── 恢復歷史對話
今天最大的收穫是:
開發一個應用程式,不只是把功能分別完成,更重要的是讓這些功能可以整合在一起,形成使用者真正能操作的流程。
從最初只會使用 Postman 測試 Spring Boot API,到現在可以直接在瀏覽器查看健康紀錄、產生 AI 摘要,以及接續歷史聊天,真的很有成就感。
雖然目前還有很多可以改善的地方,但至少已經完成一個具備前端、後端、資料庫與 AI 串接功能的智慧健康助理原型!
從 Day 1 開始,我們一路學習 Java、Spring Boot、REST API、H2 Database,再到生成式 AI API 與前端網頁整合。
原本只是想做一個簡單的健康紀錄工具,沒想到最後真的一步一步完成了智慧健康助理的基本原型。
最後一天,我準備回顧整個專案的開發過程。
包含:
也會整理這 30 天最大的收穫。
Day 30,我們明天見!